feat(heartbeat): add skipIfNoAssignments policy to suppress idle timer wakes - #168
Conversation
…r wakes Agents with no active work (no issues in todo/in_progress/blocked) still wake on every heartbeat interval, consuming resources and generating noise in wakeup logs. Add a `skipIfNoAssignments` boolean to the heartbeat policy. When enabled, `enqueueWakeup` performs a pre-flight count query before processing a timer wake — if the agent has zero assigned issues in an active status, it records a skipped request and returns early. Event-triggered wakes (assignment, on_demand, automation) are unaffected. The query hits the existing `issues_company_assignee_status_idx` index so there is no additional DB overhead at scale. Fixes paperclipai#39 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds a Key changes:
Issues found:
Confidence Score: 2/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[tickTimers tick] --> B{policy.enabled &&\nintervalSec > 0?}
B -- No --> Z[skip agent, no DB write]
B -- Yes --> C{elapsedMs >=\nintervalSec * 1000?}
C -- No --> Z
C -- Yes --> D[enqueueWakeup\nsource = 'timer']
D --> E{policy.enabled?}
E -- No --> F[writeSkippedRequest\n'heartbeat.disabled'\nreturn null]
E -- Yes --> G{policy.skipIfNoAssignments?}
G -- No --> H[continue normal\nwakeup flow]
G -- Yes --> I[COUNT query on issues\nwhere assigneeAgentId = agentId\nstatus IN todo/in_progress/blocked]
I --> J{count == 0?}
J -- No --> H
J -- Yes --> K[writeSkippedRequest\n'heartbeat.skipIfNoAssignments'\nreturn null]
K --> L[❌ lastHeartbeatAt NOT updated]
L --> M[Next tickTimers tick:\nelapsedMs still >= interval\n→ enqueueWakeup called AGAIN]
H --> N[Run queued & started]
N --> O[finalizeAgentStatus\nupdates lastHeartbeatAt ✅]
Last reviewed commit: 4c291b0 |
| if (assignedCount === 0) { | ||
| await writeSkippedRequest("heartbeat.skipIfNoAssignments"); | ||
| return null; | ||
| } |
There was a problem hiding this comment.
Skip does not reset lastHeartbeatAt, causing per-tick flooding
writeSkippedRequest writes to agentWakeupRequests and returns null, but it never updates agent.lastHeartbeatAt. The interval timer in tickTimers (line 2234-2236) uses lastHeartbeatAt as its baseline:
const baseline = new Date(agent.lastHeartbeatAt ?? agent.createdAt).getTime();
const elapsedMs = now.getTime() - baseline;
if (elapsedMs < policy.intervalSec * 1000) continue;Once elapsedMs >= intervalSec * 1000, every single tickTimers invocation will call enqueueWakeup for that agent, because lastHeartbeatAt is only ever updated inside finalizeAgentStatus (called on run completion). With skipIfNoAssignments: true and no assignments, no run ever starts, so lastHeartbeatAt is never refreshed.
Result: instead of one skipped row per interval, you get one skipped row per tickTimers tick — potentially dozens per minute — the exact "wakeup log noise" the PR is trying to eliminate.
The fix is to update lastHeartbeatAt on a skip (or update the tickTimers baseline) so the interval correctly resets:
if (assignedCount === 0) {
await writeSkippedRequest("heartbeat.skipIfNoAssignments");
// Reset the heartbeat baseline so the interval is respected
await db
.update(agents)
.set({ lastHeartbeatAt: new Date(), updatedAt: new Date() })
.where(eq(agents.id, agentId));
return null;
}…nt interval flooding
|
Good catch from Greptile — fixed. Added a |
…ness (paperclipai#168) Three CEO behavior fixes: 1. Handoff cooldown: CEO must not post follow-up comments on issues within 15 minutes of reassigning them. The generic timer heartbeat was posting "please prioritize" nudges immediately after handoff, wasting tokens while the new assignee was still picking up the work. 2. Forbidden operations table: Explicit list of operations the CEO must never attempt (SSH, GitHub, git push, Docker, code writing) with delegation instructions. Prevents the 10+ failed SSH attempts pattern observed on DLD-1783. 3. Gstack browse availability: Documents that the headless browser IS available in the container runtime, both for CEO (strategic checks only) and all agents (full QA testing). Prevents false "browser not available" claims. Also adds handoff discipline section to CEO AGENTS.md and headless browser documentation to the default AGENTS.md for all agents. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…wakes when no actionable work is queued
## Problem
Agents with a heartbeat timer consume tokens on every interval even when their only assigned work is in a non-actionable state. Real example: an OpenClaw gateway agent on a 120s interval with one assigned issue in `blocked` status burned 8 Claude invocations in 2 hours making zero forward progress — the heartbeat cannot unblock the ticket, but it still runs.
## Solution
Add a `skipIfNoActionableAssignments` boolean to the heartbeat policy (default `false` for backwards compatibility). When enabled, on a timer-triggered wake, `enqueueWakeup` runs a `COUNT(*)` query for issues assigned to the agent with status in `('todo', 'backlog')`. If the count is 0, it records a skipped wakeup request with reason `heartbeat.skipIfNoActionableAssignments`, resets `lastHeartbeatAt`, and returns early. Event-triggered wakes (assignment, on-demand, automation) are unaffected.
A matching UI toggle is added to the Advanced Run Policy section on the agent configuration form.
## Relationship to paperclipai#168
This proposal is a refinement of the idea in paperclipai#168 (open, by @Logesh-waran2003). Two intentional differences:
1. **Narrower actionable status set.** paperclipai#168 skips only when *no* issues are in `('todo', 'in_progress', 'blocked')`. This PR skips unless an issue is in `('todo', 'backlog')` — `in_progress` and `blocked` are excluded. Rationale: `in_progress` means a run already holds a checkout lock on the issue (or crashed and left a stale one); in neither case does the heartbeat help productively. `blocked` is precisely the case we cannot progress.
2. **Renamed field** to `skipIfNoActionableAssignments` so the name matches the semantics — "no actionable" rather than "no" assignments.
3. **Adds a UI toggle** on the Run Policy card so users can flip the behavior without JSON editing.
We propose this PR supersedes paperclipai#168. Happy to pick up any feedback either has received.
## Verification
End-to-end verified on a Paperclip instance running this code:
- An agent with interval=120s and one assigned issue in `blocked` status produces `agent_wakeup_requests` rows with `reason=heartbeat.skipIfNoActionableAssignments`, `status=skipped` every interval, and zero `heartbeat_runs` rows.
- Flipping the same issue to `todo` causes the next timer tick to produce a real `heartbeat_runs` row.
- Reverting to `blocked` causes skips to resume.
- Unassigning the issue entirely also produces skips (existing no-assignment path).
## Files
- `server/src/services/heartbeat.ts` — policy parser + skip check in `enqueueWakeup`.
- `ui/src/components/AgentConfigForm.tsx` — new `ToggleField` in Advanced Run Policy.
|
Thanks for the contribution! This PR has been inactive for a while and has drifted from the current codebase. Closing during triage to keep the queue manageable — please reopen or resubmit if it's still relevant. |
Problem
Agents with no active work still wake on every heartbeat interval. If an agent has no issues assigned (or all issues are done/cancelled), it fires a full wakeup cycle — consuming resources and polluting wakeup logs with noise.
Solution
Add a
skipIfNoAssignmentsboolean to the heartbeat policy (defaults tofalsefor backwards compatibility). When enabled:enqueueWakeupruns a pre-flight count query for issues assigned to the agent with status in['todo', 'in_progress', 'blocked']"heartbeat.skipIfNoAssignments"and returns earlyassignment,on_demand,automation) are completely unaffected — the guard is scoped tosource === "timer"onlyImplementation
Two changes in
server/src/services/heartbeat.ts:parseHeartbeatPolicy— parse the new field:enqueueWakeup— pre-flight guard before processing:The query hits the existing
issues_company_assignee_status_idxindex — no additional DB overhead at scale.No schema migration needed —
skipIfNoAssignmentslives in the existingruntimeConfigJSONB column on agents.Fixes #39